Defensor 开发小记

2021-11-02

注:本文对 gradle transform api 和 ASM api 的使用不做讨论,请自行查阅相关文档。

阅读本文需要对 jvm 指令有一定了解。

defensor 中译 守护神,本质上是一个工具类集合,对一些常用的 api 做了一层包装,然后通过 aop 的方式插入到代码中,防止因空指针、数组越界等常见的高频异常而导致 app 崩溃。

举个🌰:

开发阶段的一行代码: int i = list.get(10);

在编译期间,会被修改成 int i = CollectionDefensor.get(list, 10);

而这个 get 方法的逻辑为:

1
2
3
if (list == null) return defValue
else if (index < 0 || index >= list.size()) return defValue
else return list.get(index)

之所以用 aop 的方式而不是直接在开发阶段直接调用,是因为要对团队成员去宣讲有这么个工具类,然后让大家以后写 list.get(i) 的时候都改写成 CollectionDefensor.get(list, i),很显然这很难,因为不可能让每个人都按照你的要求来,而且增加了代码量,还极有可能会漏写,这种方式不仅增加学习成本,而且不稳定;而通过 aop 的方式在编译期遍历所有的class文件然后做修改,相当于收敛了修改入口,而且对上层开发无感知。

为什么叫 defensor

开发 defensor 属于灵光一现的想法,因为最近在反编译拼夕夕的时候,发现代码里充斥着 NullPointerCrashHandler 这个类,很明显这些代码是在编译期被插进去了,因为包名叫 aop_defensor 😂,觉得名字寓意很棒,就直接拿来用了。或者你可以理解这个项目是对拼夕夕 aop_defensor 这个模块增加了gradle plugin 的实现?因为逆向都算不上,拼夕夕的这个 gradle plugin 的代码我们并看不到。

defensor 实现原理

Type\Example Before After
接口/实例方法 -> 静态方法 int i = list.get(index) int i = CollectionDefensor.get(list, index)
Array[i] -> 静态方法 int i = arr[index] int i = CollectionDefensor.get(arr, index)
修改继承关系 CustomDialog extends Dialog CustomDialog extends SafeDialog
修改new对象 new JSONArray(“”) new SafeJSONArray(“[]”)

接口/实例方法 -> 静态方法

这里以 Test 作为测试,我们希望在编译期把 original 的代码修改成 expected 的代码。

1
2
3
4
5
6
7
8
9
import java.util.List;
public class Test {
public String getItem(List<String> list, int index) {
// original
return list.get(index);
// expected
// return CollectionDefensor.get(list, index);
}
}

我们可以分别输出 originalexpected 的字节码,对比一下两份字节码的差别。

javap -c Test.class 输出 original 的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public java.lang.String getItem(java.util.List<java.lang.String>, int);
Code:
0: aload_1
1: iload_2
2: invokeinterface #2, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
7: checkcast #3 // class java/lang/String
10: areturn
}

注释掉 original,放开 expected 后的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public java.lang.String getItem(java.util.List<java.lang.String>, int);
Code:
0: aload_1
1: iload_2
2: invokestatic #38 // Method defensor/CollectionDefensor.get:(Ljava/util/List;I)Ljava/lang/Object;
5: checkcast #3 // class java/lang/String
8: areturn
}

此处需要甩出一份 JVM 指令集

通过对比不难发现,getItem 方法的字节码只有一行不同,originalinvokeinterface,而 expectedinvokestatic,因为 original 的代码是 list.get(index),而 java.util.List 是一个接口,所以是 invokeinterface 调用接口方法;同理,CollectionDefensor.get(list, index)invokestatic 调用静态方法。

所以 接口/实例方法 -> 静态方法 其实就是 invokeinterface/invokevirtual -> invokestatic,所以只需要在

visitMethodInsn 的时候筛选出满足替换条件的方法并替换就👌。下面贴出将 list.get(index) 替换成 CollectionDefensor.get(list, index) 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
fun main() {
val file = File("/Users/panda/IdeaProjects/Demo/out/production/Demo/Test.class")
val cr = ClassReader(file.inputStream())
val cw = ClassWriter(cr, 0)
cr.accept(object : ClassVisitor(Opcodes.ASM9, cw) {
override fun visitMethod(
access: Int,
name: String?,
descriptor: String?,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
val mv = super.visitMethod(access, name, descriptor, signature, exceptions)
return object : MethodVisitor(Opcodes.ASM9, mv) {
override fun visitMethodInsn(
opcode: Int,
owner: String?,
name: String?,
descriptor: String?,
isInterface: Boolean
) {
if (opcode == Opcodes.INVOKEINTERFACE &&
owner == "java/util/List" &&
name == "get" &&
descriptor == "(I)Ljava/lang/Object;" &&
isInterface
) {
super.visitMethodInsn(
Opcodes.INVOKESTATIC,
"defensor/CollectionDefensor",
"get",
"(Ljava/util/List;I)Ljava/lang/Object;",
false
)
return
}
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
}
}
}
}, 0)

with(File("/Users/panda/IdeaProjects/Demo/out/production/Demo/asm/Test.class")) {
parentFile.mkdirs()
writeBytes(cw.toByteArray())
}
}

Array[i] -> 静态方法

我们把上面的 Test 稍微修改一下:

1
2
3
4
5
6
7
8
public class Test {
public int getItem(int[] arr, int index) {
// original
return arr[index];
// expected
// return CollectionDefensor.get(arr, index);
}
}

然后看看它的字节码的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public int getItem(int[], int);
Code:
0: aload_1 // 将本地变量表中index为1的引用类型的变量推到栈顶(即 arr)
1: iload_2 // 将本地变量表中index为2的 int 型的变量推到栈顶(即 index)
2: iaload // 将 int 型数组指定索引的值推送至栈顶(即 arr[index])
3: ireturn // return int
}

那么我们大胆猜测一下,如果想要将类似 arr[index] 的代码替换为 CollectionDefensor.get(arr, index) , 只需要将 iaload (当然,也可以是 laloadfaloaddaload caloadsaload) 指令替换为 invokestatic 即可。事实证明,这是对的,但是,除了 aaloadbaload

aaload 指令的含义是将引用类型数组指定索引的值推至栈顶。但是我们为了保持通用性,CollectionDefensor 中只提供了 public static Object get(Object[] arr, int index) 方法,直接将 aaload 替换成 invokestatic 是不行的,需要先获取该引用类型的具体类型,在 invokestatic 之后再添加一条 checkcast 到该具体类型的指令方可。

baload 指令的含义是将 byteboolean 类型数组指定索引的值推至栈顶。为什么 byteboolean 要共用 baload(还有 bastore)指令呢?《Java虚拟机规范》里面有相关的说明:

Although the Java Virtual Machine defines a boolean type, it only provides very limited support for it. There are no Java Virtual Machine instructions solely dedicated to operations on boolean values. Instead, expressions in the Java programming language that operate on boolean values are compiled to use values of the Java Virtual Machine int data type.

The Java Virtual Machine does directly support boolean arrays. Its newarray instruction (§newarray) enables creation of boolean arrays. Arrays of type boolean are accessed and modified using the byte array instructions baload and bastore (§baload, §bastore).

In Oracle’s Java Virtual Machine implementation, boolean arrays in the Java programming language are encoded as Java Virtual Machine byte arrays, using 8 bits per boolean element.

The Java Virtual Machine encodes boolean array components using 1 to represent true and 0 to represent false. Where Java programming language booleanvalues are mapped by compilers to values of Java Virtual Machine type int, the compilers must use the same encoding. 1

所以我们直接替换 baloadinvokestatic 也是不行的,因为我们不知道是要调用 public static byte get(byte[] arr, int index) 还是要调用 public static boolean get(boolean[] arr, int index)

所以也是要先获取到具体的类型,再取决于调用哪个方法。

那么如何获取 aaload / baload 具体的类型呢?我们将 Test 代码再稍微改动一下:

1
2
3
4
5
6
7
8
public class Test {
public byte getByteItem(byte[] arr, int index) {
return arr[index];
}
public boolean getBooleanItem(boolean[] arr, int index) {
return arr[index];
}
}

输出字节码,这次我们 -v,输出更详细的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
Classfile /Users/panda/IdeaProjects/Demo/out/production/Demo/Test.class
Last modified 2021108日; size 474 bytes
MD5 checksum 22890e487b8c5b6a9bd967dc994c725c
Compiled from "Test.java"
public class Test
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #2 // Test
super_class: #3 // java/lang/Object
interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool:
#1 = Methodref #3.#22 // java/lang/Object."<init>":()V
#2 = Class #23 // Test
#3 = Class #24 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 LTest;
#11 = Utf8 getByteItem
#12 = Utf8 ([BI)B
#13 = Utf8 arr
#14 = Utf8 [B
#15 = Utf8 index
#16 = Utf8 I
#17 = Utf8 getBooleanItem
#18 = Utf8 ([ZI)Z
#19 = Utf8 [Z
#20 = Utf8 SourceFile
#21 = Utf8 Test.java
#22 = NameAndType #4:#5 // "<init>":()V
#23 = Utf8 Test
#24 = Utf8 java/lang/Object
{
public Test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTest;

public byte getByteItem(byte[], int);
descriptor: ([BI)B
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_1
1: iload_2
2: baload
3: ireturn
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this LTest;
0 4 1 arr [B
0 4 2 index I

public boolean getBooleanItem(boolean[], int);
descriptor: ([ZI)Z
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_1
1: iload_2
2: baload
3: ireturn
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this LTest;
0 4 1 arr [Z
0 4 2 index I
}
SourceFile: "Test.java"

果然,getByteItem 和 getBooleanItem 的 Code 完全一样,如果我们仔细观察会发现,这两个方法的 LocalVariableTable 还是有区别的,getByteItem 的本地变量表中 arr 的 Signature 是 [B ,getBooleanItem 的本地变量表中 arr 的 Signature 是 [Z ,所以我们如果能够拿到 arr 的签名,就拿到了数组的具体的类型。

我们再回过头想一下 jvm 是如何获取数组指定索引的值的:

  1. 将数组的引用推到栈顶,其指令可能为 aloadgetfieldgetstatic
  2. 将int索引推到栈顶,其指令可能为 iconst_0iconst_5bipushsipushldciload
  3. 将该数组指定索引的值推至栈顶,其指令可能为 (i|l|f|d|a|b|c|s)aload

所以我们只需要这三条指令满足上述条件,则为获取数组指定索引的值。而 getfieldgetstatic 指令可以拿到数组的 descriptor;而 aload 指令可以拿到本地变量表的索引,通过索引可以获取到本地数组的 Signature,至此,数组的具体类型已经可以获取到了。

这里一个坑,本地变量表中的 Slot 是可能会复用的,所以通过aload 的索引去获取变量表中的数组变量,需要确保拿到的变量是正确的。

再来一个坑,需要过滤掉多维数组。

参考